De datos en bruto a información útil para decidir con tidyverse
Dpto. de Fundamentos del Análisis Económico. Universidad de Alicante
El desafío real del análisis de datos:
80% del tiempo de trabajo “sucio” : limpieza y preparación
20% del tiempo: análisis y modelización
tidyverse incluye una colección de bibliotecas con herramientes eficientes para el proceso de “tratamiento de datos” (“data wrangling”)
1.- Cada columna es una variable: mide el mismo atributo entre unidades
2.- Cada fila es una observación (caso): misma unidad a través de atributos
3.- Cada celda es un valor
Tenemos información similar y no redundante en una misma tabla
Es una forma natural (variable = vector columna) para trabajar con datos
tidyverse es eficiente con datos ordenadosOtras estructuras como esta pueden tener sentido para mostrar información (o por convenciones)
La visualización es atractiva, PERO sobran filas para analizar los datos: ej., total de personas con hijos y sin pareja entre 30 y 39 años
Contexto: Cadena de venta al por menor scon 12 tiendas en España
Objetivo: Analizar rendimiento de ventas para toma de decisiones estratégicas
La mayoría de operaciones pueden realizarse combinando 5 “verbos”:
select(): selecciona columnas (variables)
filter(): filtra (extraer) filas
mutate(): crea nuevas columnas
arrange(): ordena filas
summarize(): crea resúmenes de la tabla
Más la tubería %>% o |>
y group_by()
NOTA: existe una colección de “chuletas” de R, p.e., para transformación.
Todos tienen como primer argumento un data frame, los siguientes describen qué hacer (con columnas o filas) y devuelven otro data frame
select()filter()%>% o |>datos %>% filter(condicion) equivale a filter(datos, condicion)
El anidamiento con tuberías sigue el flujo natural de lectura
# Por rango de columnas
ventas |> select(id_venta:id_tienda)
# Excluir columnas
ventas |> select(-descuento_porcentaje, -descuento_aplicado)
# Por patrón de nombre
ventas |> select(starts_with("id_"))
ventas |> select(ends_with("_porcentaje"))
ventas |> select(contains("descuento"))
# Por tipo de dato
ventas |> select(where(is.numeric))
ventas |> select(where(is.character))pull(): extrae una única columna, como vectormutate()lubridate)arrange()desc()summarize()ventas |>
summarize(
total_ventas = n(), # Volumen (núm. de filas)
ingresos_totales = sum(total), # Ingresos
ingresos_promedio = mean(total),
ingresos_mediano = median(total),
descuento_promedio = mean(descuento_porcentaje), # Descuentos
descuento_total = sum(descuento_aplicado),
unidades_vendidas = sum(cantidad), # Productos
clientes_unicos = n_distinct(id_cliente) # Clientes
# (núm. de filas distintas)
)group_by(): Análisis por Gruposgroup_by() + summarize() = el poder de la agregación
Concepto: cambiar el nivel de análisis: de transacciones a tiendas, productos, etc.)
En Excel: Tablas dinámicas, AGRUPARPOR() (y SUMAR.SI/SUMIF)
mutate() con group_by()Diferencia Clave:
group_by() + summarize(): Reduce filas (nuevo dataset agregado a nivel de los grupos)
group_by() + mutate(): Mantiene filas (añade columnas calculadas por grupo a nivel de la tabla original)
Ejemplo: Porcentaje de las ventas mensuales que representa cada transacción
ungroup()IMPORTANTE: No olvidar ungroup() o .groups = "drop" después de terminar operaciones agrupadas
Cálculo de Porcentajes Globales: sin desagrupar, sum(total) suma por tienda → siempre da 100%
ungroup() (cont.)slice(), slice_head()), aleatoriamente (slice_sample()), etc.distinct(): extrae sólo las filas únicas (una o varias variables)drop_na() y replace_na(): elimina/reemplaza filas con valores ausentestidyverseMuchas funciones son equivalentes a otras de R base:
parse_number(), parse_factor(), etc. por as.numeric(), as.factor(), etc.
bind_cols() y bind_rows() por cbind() y rbind()
if_else() y case_when() para ejecución condicional (ifelse())
cut_interval(), cut_number(), cut_width()lubridate: year(), month(), `day(), quarter(), week()rename(): cambiar el nombre de una columnaacross(): aplica la misma transformación a múltiples columnasOperadores aritméticos (+, -, *, /, ^, %/%, %%) y lógicos (<, <=, >, >=, !=)
Funciones como log(), lag(), lead(), cumsum(), row_number() etc.
count(): cuenta los valores únicos de una o más variablesMedidas de centralidad y de dispersión: mean(x), median(x), sd(x), IQR(x)
Medidas de rango: min(x), quantile(x, 0.25), max(x)
Medidas de posición: first(x), nth(x, 2), last(x).
Sumas, productos, etc.
Conteos:
n(): observaciones totales (tamaño del grupo)
n_distinct(x): filas distintas en x
Formato ANCHO:
| tienda | Q1 | Q2 | Q3 | Q4 |
|---|---|---|---|---|
| Madrid | 145 | 158 | 151 | 169 |
| Barcelona | 152 | 164 | 156 | 175 |
| Valencia | 138 | 151 | 149 | 162 |
(datos del informe enviado por gestores)
Formato LARGO:
| tienda | trimestre | ventas |
|---|---|---|
| Madrid | Q1 | 145 |
| Madrid | Q2 | 158 |
| Madrid | Q3 | 151 |
| Madrid | Q4 | 169 |
| Barcelona | Q1 | 152 |
| … | … | … |
Los trimestres son columnas
Cada tienda = 1 fila
Análisis de datos a menudo complicado
Los trimestres son valores
Cada tienda-trimestre = 1 fila
No adecuado para tablas de presentación final
pivot_longer(): girar de ancho a largotabla a cambiar de forma
nombres o índices (numéricos) de las columnas a girar: representan valores, no variables
nombre para la nueva variable que tendrá, como valores, esas antiguas columnas a girar
nombre para la nueva variable que tendrá como valores las antiguas celdas
pivot_wider(): girar de largo a anchotabla a cambiar de forma
nombre de la variable cuyos valores dan nombre a las nuevas columnas
nombre de la variable de cuyas celdas toman los valores las nuevas columnas
¿Qué trimestres superan 160 en ventas?
Calcular crecimiento
formato ancho: cada columna manualmente (Excel), difícil con muchas fechas
formato largo: una línea, independientemente del número
Gráfico temporal
ggplot(ventas_ancho) +
geom_line(aes(x = 1:4, y = c(Q1[1], Q2[1], Q3[1], Q4[1])), color = "red") +
geom_line(aes(x = 1:4, y = c(Q1[2], Q2[2], Q3[2], Q4[2])), color = "blue") +
geom_line(aes(x = 1:4, y = c(Q1[3], Q2[3], Q3[3], Q4[3])), color = "green")
ggplot(ventas_largo, aes(x = trimestre, y = ventas,
color = tienda, group = tienda)) +
geom_line() + geom_point()Problemas prácticos con formato ancho para analizar datos:
Algunas tareas son imposibles
Código repetitivo y propenso a errores
No escala: con 12 meses o 50 tiendas se vuelve inmanejable
separate(): dividir una columna en múltiples variables indicando un separador o vector de posiciones en las que dividirconvert = TRUE intenta convertir el tipo (no mantener carácter)unite(): combinar múltiples columnas en unaVentajas del Diseño Relacional:
# Ejemplo: información duplicada vs relacional
# MAL: Todo en una tabla (información repetida)
ventas_todo_junto <- ventas %>%
left_join(productos, by = "id_producto") %>%
left_join(tiendas, by = "id_tienda") %>%
left_join(clientes, by = "id_cliente")
# BIEN: Tablas separadas, unir solo cuando necesario
# ventas, productos, tiendas, clientes son tablas independientes# Datos de ejemplo para ilustrar
df1 <- tibble(id = 1:3, valor_x = c("A", "B", "C"))
df2 <- tibble(id = c(1, 2, 4), valor_y = c("X", "Y", "Z"))
# inner_join: solo coincidencias
inner_join(df1, df2, by = "id") # resultado: 1, 2
# left_join: todas de la izquierda
left_join(df1, df2, by = "id") # resultado: 1, 2, 3 (3 con NA)
# right_join: todas de la derecha
right_join(df1, df2, by = "id") # resultado: 1, 2, 4 (4 con NA)
# full_join: todas de ambas
full_join(df1, df2, by = "id") # resultado: 1, 2, 3, 4Objetivo: Análisis completo con información de todas las tablas
# Crear dataset completo para análisis
ventas_completo <- ventas %>%
# Información de productos
left_join(productos %>% select(id_producto, nombre_producto, id_categoria, precio, costo),
by = "id_producto") %>%
# Información de categorías
left_join(categorias, by = "id_categoria") %>%
# Información de tiendas
left_join(tiendas %>% select(id_tienda, nombre_tienda, region, tamaño_m2),
by = "id_tienda") %>%
# Información de clientes
left_join(clientes %>% select(id_cliente, programa_fidelidad, fecha_registro),
by = "id_cliente") %>%
# Información de empleados
left_join(empleados %>% select(id_empleado, puesto),
by = "id_empleado")
# Ahora podemos hacer análisis complejos
glimpse(ventas_completo)Análisis Enriquecido:
# Rentabilidad por categoría y región
analisis_rentabilidad <- ventas_completo %>%
mutate(
margen_venta = precio - costo,
rentabilidad = margen_venta * cantidad
) %>%
group_by(nombre_categoria, region) %>%
summarize(
num_ventas = n(),
ingresos = sum(total),
rentabilidad_total = sum(rentabilidad, na.rm = TRUE),
margen_porcentaje = round(mean(margen_venta / precio * 100, na.rm = TRUE), 1),
.groups = "drop"
) %>%
arrange(desc(rentabilidad_total))
head(analisis_rentabilidad, 10)Caso de Uso: Performance por región y mes
# Primero unir con info de tiendas para obtener región
performance_regional <- ventas %>%
left_join(tiendas %>% select(id_tienda, region), by = "id_tienda") %>%
group_by(region, año, mes) %>%
summarize(
ventas_totales = n(),
ingresos = sum(total),
ticket_promedio = mean(total),
.groups = "drop"
) %>%
arrange(region, año, mes)
head(performance_regional, 10)Caso de Uso: Performance por región y mes
# Primero unir con info de tiendas para obtener región
performance_regional <- ventas %>%
left_join(tiendas %>% select(id_tienda, region), by = "id_tienda") %>%
group_by(region, año, mes) %>%
summarize(
ventas_totales = n(),
ingresos = sum(total),
ticket_promedio = mean(total),
.groups = "drop"
) %>%
arrange(region, año, mes)
head(performance_regional, 10)# Comparación mensual por región
crecimiento_mensual <- performance_regional %>%
group_by(region, mes) %>%
arrange(año) %>%
mutate(
ingresos_año_anterior = lag(ingresos),
crecimiento_absoluto = ingresos - ingresos_año_anterior,
crecimiento_porcentual = round(
(ingresos - ingresos_año_anterior) / ingresos_año_anterior * 100, 1
)
) %>%
filter(!is.na(crecimiento_porcentual))
# Regiones con mayor crecimiento
crecimiento_mensual %>%
filter(año == 2023) %>%
group_by(region) %>%
summarize(crecimiento_promedio = mean(crecimiento_porcentual, na.rm = TRUE)) %>%
arrange(desc(crecimiento_promedio))semi_join: Mantener filas que tienen coincidencia
anti_join: Mantener filas que NO tienen coincidencia
# Clientes registrados pero sin compras
clientes_sin_compras <- clientes %>%
anti_join(ventas, by = "id_cliente")
nrow(clientes_sin_compras) # Oportunidad de marketing
# Productos en catálogo pero nunca vendidos
productos_sin_vender <- productos %>%
anti_join(ventas, by = "id_producto") %>%
filter(activo == TRUE) # y que estén activos
nrow(productos_sin_vender) # ¿Eliminar del catálogo?# Crear tabla de objetivos mensuales por tienda
objetivos <- tibble(
id_tienda = rep(1:12, each = 12),
mes = rep(1:12, times = 12),
objetivo_ingresos = runif(144, 30000, 80000)
)
# Comparar ventas reales vs objetivos
comparacion_objetivos <- ventas %>%
group_by(id_tienda, mes) %>%
summarize(ingresos_reales = sum(total), .groups = "drop") %>%
left_join(objetivos, by = c("id_tienda", "mes")) %>%
mutate(
diferencia = ingresos_reales - objetivo_ingresos,
cumplimiento_pct = round(ingresos_reales / objetivo_ingresos * 100, 1),
cumple_objetivo = cumplimiento_pct >= 100
)
# Tiendas que más superan objetivos
comparacion_objetivos %>%
filter(cumple_objetivo) %>%
group_by(id_tienda) %>%
summarize(
meses_cumplidos = n(),
exceso_promedio = mean(diferencia)
) %>%
arrange(desc(meses_cumplidos))Contexto: Gerente de operaciones preocupado por devoluciones
Preguntas: 1. ¿Qué productos tienen más devoluciones? 2. ¿Hay patrones por tienda o categoría? 3. ¿Impacto financiero de las devoluciones? 4. ¿Recomendaciones accionables?
# Visión general de devoluciones
glimpse(devoluciones)
# Estadísticas básicas
devoluciones %>%
summarize(
total_devoluciones = n(),
tasa_devolucion = n() / nrow(ventas) * 100,
reembolso_total = sum(reembolso),
reembolso_promedio = mean(reembolso),
dias_promedio = mean(as.numeric(fecha_devolucion -
ventas$fecha[match(id_venta, ventas$id_venta)]), na.rm = TRUE)
)# Distribución de motivos
analisis_motivos <- devoluciones %>%
group_by(motivo) %>%
summarize(
num_devoluciones = n(),
reembolso_total = sum(reembolso),
.groups = "drop"
) %>%
mutate(
pct_devoluciones = round(num_devoluciones / sum(num_devoluciones) * 100, 1),
pct_reembolso = round(reembolso_total / sum(reembolso_total) * 100, 1)
) %>%
arrange(desc(num_devoluciones))
analisis_motivos
# Visualización
ggplot(analisis_motivos, aes(x = reorder(motivo, num_devoluciones),
y = num_devoluciones)) +
geom_col(fill = "steelblue") +
coord_flip() +
labs(title = "Devoluciones por Motivo",
x = "Motivo", y = "Número de Devoluciones") +
theme_minimal()# Unir devoluciones con ventas y productos
productos_devueltos <- devoluciones %>%
left_join(ventas %>% select(id_venta, id_producto, id_tienda),
by = "id_venta") %>%
left_join(productos %>% select(id_producto, nombre_producto, id_categoria),
by = "id_producto") %>%
left_join(categorias %>% select(id_categoria, nombre_categoria),
by = "id_categoria")
# Top productos con más devoluciones
productos_problema <- productos_devueltos %>%
group_by(id_producto, nombre_producto, nombre_categoria) %>%
summarize(
num_devoluciones = n(),
reembolso_total = sum(reembolso),
.groups = "drop"
) %>%
arrange(desc(num_devoluciones)) %>%
head(20)
productos_problema
# Calcular tasa de devolución por producto
tasa_devolucion_producto <- ventas %>%
group_by(id_producto) %>%
summarize(
veces_vendido = n(),
.groups = "drop"
) %>%
left_join(
devoluciones %>%
left_join(ventas %>% select(id_venta, id_producto), by = "id_venta") %>%
group_by(id_producto) %>%
summarize(veces_devuelto = n(), .groups = "drop"),
by = "id_producto"
) %>%
mutate(
veces_devuelto = replace_na(veces_devuelto, 0),
tasa_devolucion = round(veces_devuelto / veces_vendido * 100, 2)
) %>%
filter(veces_vendido >= 10) %>% # solo productos con suficientes ventas
arrange(desc(tasa_devolucion))
head(tasa_devolucion_producto, 10)# Devoluciones por tienda
devoluciones_tienda <- productos_devueltos %>%
left_join(tiendas %>% select(id_tienda, nombre_tienda, region),
by = "id_tienda") %>%
group_by(id_tienda, nombre_tienda, region) %>%
summarize(
num_devoluciones = n(),
reembolso_total = sum(reembolso),
.groups = "drop"
)
# Calcular tasa de devolución por tienda
ventas_por_tienda <- ventas %>%
group_by(id_tienda) %>%
summarize(num_ventas = n(), .groups = "drop")
comparacion_tiendas <- ventas_por_tienda %>%
left_join(devoluciones_tienda, by = "id_tienda") %>%
mutate(
num_devoluciones = replace_na(num_devoluciones, 0),
tasa_devolucion = round(num_devoluciones / num_ventas * 100, 2)
) %>%
left_join(tiendas %>% select(id_tienda, nombre_tienda, region),
by = "id_tienda") %>%
arrange(desc(tasa_devolucion))
comparacion_tiendas
# Por categoría
devoluciones_categoria <- productos_devueltos %>%
group_by(nombre_categoria) %>%
summarize(
num_devoluciones = n(),
reembolso_total = sum(reembolso),
motivo_principal = names(sort(table(motivo), decreasing = TRUE))[1],
.groups = "drop"
) %>%
arrange(desc(num_devoluciones))
devoluciones_categoria# Impacto total
impacto_financiero <- ventas %>%
summarize(
ingresos_brutos = sum(total),
.groups = "drop"
) %>%
mutate(
reembolsos = sum(devoluciones$reembolso),
ingresos_netos = ingresos_brutos - reembolsos,
tasa_devolucion_financiera = round(reembolsos / ingresos_brutos * 100, 2)
)
impacto_financiero
# Impacto por período
impacto_temporal <- ventas %>%
mutate(año_mes = paste(año, sprintf("%02d", mes), sep = "-")) %>%
group_by(año_mes) %>%
summarize(
ingresos_brutos = sum(total),
.groups = "drop"
) %>%
left_join(
devoluciones %>%
left_join(ventas %>% select(id_venta, año, mes), by = "id_venta") %>%
mutate(año_mes = paste(año, sprintf("%02d", mes), sep = "-")) %>%
group_by(año_mes) %>%
summarize(reembolsos = sum(reembolso), .groups = "drop"),
by = "año_mes"
) %>%
mutate(
reembolsos = replace_na(reembolsos, 0),
ingresos_netos = ingresos_brutos - reembolsos,
tasa_devolucion = round(reembolsos / ingresos_brutos * 100, 2)
) %>%
arrange(año_mes)
# Visualizar tendencia
ggplot(impacto_temporal, aes(x = año_mes, y = tasa_devolucion, group = 1)) +
geom_line(color = "red", size = 1) +
geom_point() +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1)) +
labs(title = "Evolución de la Tasa de Devolución",
x = "Período", y = "Tasa de Devolución (%)")# Crear informe ejecutivo
informe_devoluciones <- list(
resumen = devoluciones %>%
summarize(
total_devoluciones = n(),
tasa_global = round(n() / nrow(ventas) * 100, 2),
costo_total = sum(reembolso)
),
productos_criticos = tasa_devolucion_producto %>%
filter(tasa_devolucion > 10) %>%
head(10),
tiendas_problema = comparacion_tiendas %>%
filter(tasa_devolucion > 7) %>%
head(5),
categorias_riesgo = devoluciones_categoria %>%
head(3)
)
# Mostrar informe
informe_devolucionesRecomendaciones:
# Identificar NAs
ventas %>%
summarize(
na_cliente = sum(is.na(id_cliente)),
na_producto = sum(is.na(id_producto)),
na_total = sum(is.na(total))
)
# Filtrar NAs
ventas_sin_na <- ventas %>%
filter(!is.na(id_cliente))
# Eliminar filas con ANY NA
ventas_completas <- ventas %>%
drop_na()
# Eliminar NAs de columnas específicas
ventas %>%
drop_na(id_cliente, total)
# Reemplazar NAs
clientes_limpio <- clientes %>%
mutate(
programa_fidelidad = replace_na(programa_fidelidad, "Básico")
)
# NA en operaciones: usar na.rm = TRUE
ventas %>%
summarize(
media_con_na = mean(descuento_porcentaje),
media_sin_na = mean(descuento_porcentaje, na.rm = TRUE)
)Error 2: Nombres de columnas con espacios o caracteres especiales
# PROBLEMA
datos_problema <- tibble(
`Nombre Producto` = c("A", "B", "C"),
`Precio (€)` = c(10, 20, 30)
)
# Usar backticks
datos_problema %>%
select(`Nombre Producto`, `Precio (€)`)
# MEJOR: Usar nombres sin espacios
datos_bien <- datos_problema %>%
rename(
nombre_producto = `Nombre Producto`,
precio_euros = `Precio (€)`
)Error 3: No verificar claves duplicadas en joins
# PROBLEMA: claves duplicadas pueden multiplicar filas
df1 <- tibble(id = c(1, 1, 2), valor = c("A", "B", "C"))
df2 <- tibble(id = c(1, 2), info = c("X", "Y"))
resultado <- left_join(df1, df2, by = "id")
nrow(resultado) # Esperamos 3, tenemos 3 (pero revisar lógica)
# VERIFICAR antes de hacer join
df1 %>% count(id) %>% filter(n > 1) # ids duplicados
df2 %>% count(id) %>% filter(n > 1) # ids duplicados
# SOLUCIÓN: decidir qué hacer con duplicados
df1_unico <- df1 %>% distinct(id, .keep_all = TRUE)Error 4: Orden de operaciones incorrecto
# USO EFICIENTE: filtrar primero, luego unir
ventas_2023 <- ventas %>%
filter(año == 2023) %>% # reduce tamaño
left_join(productos, by = "id_producto")
# USO INEFICIENTE: unir todo, luego filtrar
ventas_2023_lento <- ventas %>%
left_join(productos, by = "id_producto") %>%
filter(año == 2023)
# SELECCIONAR SOLO COLUMNAS NECESARIAS
productos_minimo <- productos %>%
select(id_producto, nombre_producto, precio)
ventas_join <- ventas %>%
left_join(productos_minimo, by = "id_producto")# Distribución de ventas
ggplot(ventas, aes(x = total)) +
geom_histogram(bins = 50, fill = "steelblue", alpha = 0.7) +
labs(title = "Distribución de Ventas",
x = "Importe Total (€)", y = "Frecuencia") +
theme_minimal()
# Ventas por día de la semana
ventas_dia <- ventas %>%
group_by(dia_semana) %>%
summarize(
num_ventas = n(),
ingresos = sum(total),
ticket_promedio = mean(total)
)
ggplot(ventas_dia, aes(x = dia_semana, y = ingresos)) +
geom_col(fill = "coral") +
labs(title = "Ingresos por Día de la Semana",
x = "Día", y = "Ingresos (€)") +
theme_minimal()
# Evolución temporal
ventas_mensual <- ventas %>%
group_by(año, mes) %>%
summarize(ingresos = sum(total), .groups = "drop") %>%
mutate(periodo = paste(año, sprintf("%02d", mes), sep = "-"))
ggplot(ventas_mensual, aes(x = periodo, y = ingresos, group = 1)) +
geom_line(color = "darkgreen", size = 1) +
geom_point() +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1)) +
labs(title = "Evolución de Ingresos Mensuales",
x = "Período", y = "Ingresos (€)")# Top 10 tiendas por ingresos
top_tiendas <- ventas %>%
left_join(tiendas %>% select(id_tienda, nombre_tienda), by = "id_tienda") %>%
group_by(nombre_tienda) %>%
summarize(ingresos = sum(total), .groups = "drop") %>%
arrange(desc(ingresos)) %>%
head(10)
ggplot(top_tiendas, aes(x = reorder(nombre_tienda, ingresos), y = ingresos)) +
geom_col(fill = "steelblue") +
coord_flip() +
labs(title = "Top 10 Tiendas por Ingresos",
x = "Tienda", y = "Ingresos (€)") +
theme_minimal()
# Comparación entre regiones
ventas_region <- ventas %>%
left_join(tiendas %>% select(id_tienda, region), by = "id_tienda") %>%
group_by(region, año) %>%
summarize(ingresos = sum(total), .groups = "drop")
ggplot(ventas_region, aes(x = factor(año), y = ingresos, fill = region)) +
geom_col(position = "dodge") +
labs(title = "Ingresos por Región y Año",
x = "Año", y = "Ingresos (€)", fill = "Región") +
theme_minimal()Usando los datos de RetailCorp: